# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, print_function, unicode_literals

__author__ = "Gina Häußge <osd@foosel.net>"
__license__ = "GNU Affero General Public License http://www.gnu.org/licenses/agpl.html"
__copyright__ = "Copyright (C) 2016 The OctoPrint Project - Released under terms of the AGPLv3 License"


import calendar
import io
import os
import re
import sys
import threading
import time
from collections import OrderedDict

import feedparser
import flask
from flask_babel import gettext

import octoprint.plugin
from octoprint import __version__ as OCTOPRINT_VERSION
from octoprint.access import ADMIN_GROUP
from octoprint.access.permissions import Permissions
from octoprint.server.util.flask import (
    check_etag,
    no_firstrun_access,
    with_revalidation_checking,
)
from octoprint.util import count, monotonic_time, utmify

PY2 = sys.version_info[0] < 3


class AnnouncementPlugin(
    octoprint.plugin.AssetPlugin,
    octoprint.plugin.SettingsPlugin,
    octoprint.plugin.BlueprintPlugin,
    octoprint.plugin.StartupPlugin,
    octoprint.plugin.TemplatePlugin,
    octoprint.plugin.EventHandlerPlugin,
):

    # noinspection PyMissingConstructor
    def __init__(self):
        self._cached_channel_configs = None
        self._cached_channel_configs_mutex = threading.RLock()

        from octoprint.vendor.awesome_slugify import Slugify

        self._slugify = Slugify()
        self._slugify.safe_chars = "-_."

    # Additional permissions hook

    def get_additional_permissions(self):
        return [
            {
                "key": "READ",
                "name": "Read announcements",
                "description": gettext("Allows to read announcements"),
                "default_groups": [ADMIN_GROUP],
                "roles": ["read"],
            },
            {
                "key": "MANAGE",
                "name": "Manage announcement subscriptions",
                "description": gettext(
                    'Allows to manage announcement subscriptions. Includes "Read announcements" '
                    "permission"
                ),
                "default_groups": [ADMIN_GROUP],
                "roles": ["manage"],
                "permissions": ["PLUGIN_ANNOUNCEMENTS_READ"],
            },
        ]

    # StartupPlugin

    def on_after_startup(self):
        # decouple channel fetching from server startup
        def fetch_data():
            self._fetch_all_channels()

        thread = threading.Thread(target=fetch_data)
        thread.daemon = True
        thread.start()

    # SettingsPlugin

    def get_settings_defaults(self):
        settings = {
            "channels": {
                "_important": {
                    "name": "Important Announcements",
                    "description": "Important announcements about OctoPrint.",
                    "priority": 1,
                    "type": "rss",
                    "url": "https://octoprint.org/feeds/important.xml",
                },
                "_releases": {
                    "name": "Release Announcements",
                    "description": "Announcements of new releases and release candidates of OctoPrint.",
                    "priority": 2,
                    "type": "rss",
                    "url": "https://octoprint.org/feeds/releases.xml",
                },
                "_blog": {
                    "name": "On the OctoBlog",
                    "description": "Development news, community spotlights, OctoPrint On Air episodes and more from the official OctoBlog.",
                    "priority": 2,
                    "type": "rss",
                    "url": "https://octoprint.org/feeds/octoblog.xml",
                },
                "_plugins": {
                    "name": "New Plugins in the Repository",
                    "description": "Announcements of new plugins released on the official Plugin Repository.",
                    "priority": 2,
                    "type": "rss",
                    "url": "https://plugins.octoprint.org/feed.xml",
                },
                "_octopi": {
                    "name": "OctoPi News",
                    "description": "News around OctoPi, the Raspberry Pi image including OctoPrint.",
                    "priority": 2,
                    "type": "rss",
                    "url": "https://octoprint.org/feeds/octopi.xml",
                },
            },
            "enabled_channels": [],
            "forced_channels": ["_important"],
            "channel_order": ["_important", "_releases", "_blog", "_plugins", "_octopi"],
            "ttl": 6 * 60,
            "display_limit": 3,
            "summary_limit": 300,
        }
        settings["enabled_channels"] = list(settings["channels"].keys())
        return settings

    def get_settings_version(self):
        return 1

    def on_settings_migrate(self, target, current):
        if current is None:
            # first version had different default feeds and only _important enabled by default
            channels = self._settings.get(["channels"])
            if "_news" in channels:
                del channels["_news"]
            if "_spotlight" in channels:
                del channels["_spotlight"]
            self._settings.set(["channels"], channels)

            enabled = self._settings.get(["enabled_channels"])
            add_blog = False
            if "_news" in enabled:
                add_blog = True
                enabled.remove("_news")
            if "_spotlight" in enabled:
                add_blog = True
                enabled.remove("_spotlight")
            if add_blog and "_blog" not in enabled:
                enabled.append("_blog")
            self._settings.set(["enabled_channels"], enabled)

    # AssetPlugin

    def get_assets(self):
        return {
            "js": ["js/announcements.js"],
            "less": ["less/announcements.less"],
            "css": ["css/announcements.css"],
        }

    # Template Plugin

    def get_template_configs(self):
        return [
            {
                "type": "settings",
                "name": gettext("Announcements"),
                "template": "announcements_settings.jinja2",
                "custom_bindings": True,
            },
            {
                "type": "navbar",
                "template": "announcements_navbar.jinja2",
                "styles": ["display: none"],
                "data_bind": "visible: loginState.hasPermission(access.permissions.PLUGIN_ANNOUNCEMENTS_READ)",
            },
        ]

    # Blueprint Plugin

    @octoprint.plugin.BlueprintPlugin.route("/channels", methods=["GET"])
    @no_firstrun_access
    @Permissions.PLUGIN_ANNOUNCEMENTS_READ.require(403)
    def get_channel_data(self):
        from octoprint.settings import valid_boolean_trues

        result = []

        force = flask.request.values.get("force", "false") in valid_boolean_trues

        enabled = self._settings.get(["enabled_channels"])
        forced = self._settings.get(["forced_channels"])

        channel_configs = self._get_channel_configs(force=force)

        def view():
            channel_data = self._fetch_all_channels(force=force)

            for key, data in channel_configs.items():
                read_until = channel_configs[key].get("read_until", None)
                entries = sorted(
                    self._to_internal_feed(
                        channel_data.get(key, []), read_until=read_until
                    ),
                    key=lambda e: e["published"],
                    reverse=True,
                )
                unread = count(filter(lambda e: not e["read"], entries))

                if read_until is None and entries:
                    last = entries[0]["published"]
                    self._mark_read_until(key, last)

                result.append(
                    {
                        "key": key,
                        "channel": data["name"],
                        "url": data["url"],
                        "description": data.get("description", ""),
                        "priority": data.get("priority", 2),
                        "enabled": key in enabled or key in forced,
                        "forced": key in forced,
                        "data": entries,
                        "unread": unread,
                    }
                )

            return flask.jsonify(channels=result)

        def etag():
            import hashlib

            hash = hashlib.sha1()

            def hash_update(value):
                hash.update(value.encode("utf-8"))

            hash_update(repr(sorted(enabled)))
            hash_update(repr(sorted(forced)))
            hash_update(OCTOPRINT_VERSION)

            for channel in sorted(channel_configs.keys()):
                hash_update(repr(channel_configs[channel]))
                channel_data = self._get_channel_data_from_cache(
                    channel, channel_configs[channel]
                )
                hash_update(repr(channel_data))

            return hash.hexdigest()

        # noinspection PyShadowingNames
        def condition(lm, etag):
            return check_etag(etag)

        return with_revalidation_checking(
            etag_factory=lambda *args, **kwargs: etag(),
            condition=lambda lm, etag: condition(lm, etag),
            unless=lambda: force,
        )(view)()

    @octoprint.plugin.BlueprintPlugin.route("/channels/<channel>", methods=["POST"])
    @no_firstrun_access
    @Permissions.PLUGIN_ANNOUNCEMENTS_READ.require(403)
    def channel_command(self, channel):
        from octoprint.server import NO_CONTENT
        from octoprint.server.util.flask import get_json_command_from_request

        valid_commands = {"read": ["until"], "toggle": []}

        command, data, response = get_json_command_from_request(
            flask.request, valid_commands=valid_commands
        )
        if response is not None:
            return response

        if command == "read":
            until = data["until"]
            self._mark_read_until(channel, until)

        elif command == "toggle":
            if not Permissions.PLUGIN_ANNOUNCEMENTS_MANAGE.can():
                return flask.make_response("Insufficient rights", 403)
            self._toggle(channel)

        return NO_CONTENT

    def is_blueprint_protected(self):
        return False

    ##~~ EventHandlerPlugin

    def on_event(self, event, payload):
        from octoprint.events import Events

        if (
            event != Events.CONNECTIVITY_CHANGED
            or not payload
            or not payload.get("new", False)
        ):
            return
        self._fetch_all_channels_async()

    # Internal Tools

    def _mark_read_until(self, channel, until):
        """Set read_until timestamp of a channel."""

        current_read_until = None
        channel_data = self._settings.get(["channels", channel], merged=True)
        if channel_data:
            current_read_until = channel_data.get("read_until", None)

        defaults = {"plugins": {"announcements": {"channels": {}}}}
        defaults["plugins"]["announcements"]["channels"][channel] = {
            "read_until": current_read_until
        }

        with self._cached_channel_configs_mutex:
            self._settings.set(
                ["channels", channel, "read_until"], until, defaults=defaults
            )
            self._settings.save()
            self._cached_channel_configs = None

    def _toggle(self, channel):
        """Toggle enable/disabled state of a channel."""

        enabled_channels = list(self._settings.get(["enabled_channels"]))

        if channel in enabled_channels:
            enabled_channels.remove(channel)
        else:
            enabled_channels.append(channel)

        self._settings.set(["enabled_channels"], enabled_channels)
        self._settings.save()

    def _get_channel_configs(self, force=False):
        """Retrieve all channel configs with sanitized keys."""

        with self._cached_channel_configs_mutex:
            if self._cached_channel_configs is None or force:
                configs = self._settings.get(["channels"], merged=True)
                order = self._settings.get(["channel_order"])
                all_keys = order + [
                    key for key in sorted(configs.keys()) if key not in order
                ]

                result = OrderedDict()
                for key in all_keys:
                    config = configs.get(key)
                    if config is None or "url" not in config or "name" not in config:
                        # strip invalid entries
                        continue
                    result[self._slugify(key)] = config
                self._cached_channel_configs = result
        return self._cached_channel_configs

    def _get_channel_config(self, key, force=False):
        """Retrieve specific channel config for channel."""

        safe_key = self._slugify(key)
        return self._get_channel_configs(force=force).get(safe_key)

    def _fetch_all_channels_async(self, force=False):
        thread = threading.Thread(
            target=self._fetch_all_channels, kwargs={"force": force}
        )
        thread.daemon = True
        thread.start()

    def _fetch_all_channels(self, force=False):
        """Fetch all channel feeds from cache or network."""

        channels = self._get_channel_configs(force=force)
        enabled = self._settings.get(["enabled_channels"])
        forced = self._settings.get(["forced_channels"])

        all_channels = {}
        for key, config in channels.items():
            if key not in enabled and key not in forced:
                continue

            if "url" not in config:
                continue

            data = self._get_channel_data(key, config, force=force)
            if data is not None:
                all_channels[key] = data

        return all_channels

    def _get_channel_data(self, key, config, force=False):
        """Fetch individual channel feed from cache/network."""

        data = None

        if not force:
            # we may use the cache, see if we have something in there
            data = self._get_channel_data_from_cache(key, config)

        if data is None:
            # cache not allowed or empty, fetch from network
            if self._connectivity_checker.online:
                data = self._get_channel_data_from_network(key, config)
            else:
                self._logger.info(
                    "Looks like we are offline, can't fetch announcements for channel {} from network".format(
                        key
                    )
                )

        return data

    def _get_channel_data_from_cache(self, key, config):
        """Fetch channel feed from cache."""

        channel_path = self._get_channel_cache_path(key)

        if os.path.exists(channel_path):
            if "ttl" in config and isinstance(config["ttl"], int):
                ttl = config["ttl"]
            else:
                ttl = self._settings.get_int(["ttl"])

            ttl *= 60
            now = time.time()
            if os.stat(channel_path).st_mtime + ttl > now:
                d = feedparser.parse(channel_path)
                self._logger.debug(
                    "Loaded channel {} from cache at {}".format(key, channel_path)
                )
                return d

        return None

    def _get_channel_data_from_network(self, key, config):
        """Fetch channel feed from network."""

        import requests

        url = config["url"]
        try:
            start = monotonic_time()
            r = requests.get(url, timeout=30)
            r.raise_for_status()
            self._logger.info(
                "Loaded channel {} from {} in {:.2}s".format(
                    key, config["url"], monotonic_time() - start
                )
            )
        except Exception:
            self._logger.exception(
                "Could not fetch channel {} from {}".format(key, config["url"])
            )
            return None

        response = r.text
        channel_path = self._get_channel_cache_path(key)
        with io.open(channel_path, mode="wt", encoding="utf-8") as f:
            f.write(response)
        return feedparser.parse(response)

    def _to_internal_feed(self, feed, read_until=None):
        """Convert feed to internal data structure."""

        result = []
        if "entries" in feed:
            for entry in feed["entries"]:
                try:
                    internal_entry = self._to_internal_entry(entry, read_until=read_until)
                    if internal_entry:
                        result.append(internal_entry)
                except Exception:
                    self._logger.exception(
                        "Error while converting entry from feed, skipping it"
                    )
        return result

    def _to_internal_entry(self, entry, read_until=None):
        """Convert feed entries to internal data structure."""

        timestamp = entry.get("published_parsed", None)
        if timestamp is None:
            timestamp = entry.get("updated_parsed", None)
        if timestamp is None:
            return None

        published = calendar.timegm(timestamp)

        read = True
        if read_until is not None:
            read = published <= read_until

        return {
            "title": entry["title"],
            "title_without_tags": _strip_tags(entry["title"]),
            "summary": _lazy_images(entry["summary"]),
            "summary_without_images": _strip_images(entry["summary"]),
            "published": published,
            "link": utmify(
                entry["link"],
                source="octoprint",
                medium="announcements",
                content=OCTOPRINT_VERSION,
            ),
            "read": read,
        }

    def _get_channel_cache_path(self, key):
        """Retrieve cache path for channel key."""

        safe_key = self._slugify(key)
        return os.path.join(self.get_plugin_data_folder(), "{}.cache".format(safe_key))


_image_tag_re = re.compile(r"<img.*?/?>")


def _strip_images(text):
    """
    >>> _strip_images("<a href='test.html'>I'm a link</a> and this is an image: <img src='foo.jpg' alt='foo'>") # doctest: +ALLOW_UNICODE
    "<a href='test.html'>I'm a link</a> and this is an image: "
    >>> _strip_images("One <img src=\\"one.jpg\\"> and two <img src='two.jpg' > and three <img src=three.jpg> and four <img src=\\"four.png\\" alt=\\"four\\">") # doctest: +ALLOW_UNICODE
    'One  and two  and three  and four '
    >>> _strip_images("No images here") # doctest: +ALLOW_UNICODE
    'No images here'
    """
    return _image_tag_re.sub("", text)


def _replace_images(text, callback):
    """
    >>> callback = lambda img: "foobar"
    >>> _replace_images("<a href='test.html'>I'm a link</a> and this is an image: <img src='foo.jpg' alt='foo'>", callback) # doctest: +ALLOW_UNICODE
    "<a href='test.html'>I'm a link</a> and this is an image: foobar"
    >>> _replace_images("One <img src=\\"one.jpg\\"> and two <img src='two.jpg' > and three <img src=three.jpg> and four <img src=\\"four.png\\" alt=\\"four\\">", callback) # doctest: +ALLOW_UNICODE
    'One foobar and two foobar and three foobar and four foobar'
    """
    result = text
    for match in _image_tag_re.finditer(text):
        tag = match.group(0)
        replaced = callback(tag)
        result = result.replace(tag, replaced)
    return result


_image_src_re = re.compile(r'src=(?P<quote>[\'"]*)(?P<src>.*?)(?P=quote)(?=\s+|>)')


def _lazy_images(text, placeholder=None):
    """
    >>> _lazy_images("<a href='test.html'>I'm a link</a> and this is an image: <img src='foo.jpg' alt='foo'>") # doctest: +ALLOW_UNICODE
    '<a href=\\'test.html\\'>I\\'m a link</a> and this is an image: <img src="" data-src=\\'foo.jpg\\' alt=\\'foo\\'>'
    >>> _lazy_images("<a href='test.html'>I'm a link</a> and this is an image: <img src='foo.jpg' alt='foo'>", placeholder="ph.png") # doctest: +ALLOW_UNICODE
    '<a href=\\'test.html\\'>I\\'m a link</a> and this is an image: <img src="ph.png" data-src=\\'foo.jpg\\' alt=\\'foo\\'>'
    >>> _lazy_images("One <img src=\\"one.jpg\\"> and two <img src='two.jpg' > and three <img src=three.jpg> and four <img src=\\"four.png\\" alt=\\"four\\">", placeholder="ph.png") # doctest: +ALLOW_UNICODE
    'One <img src="ph.png" data-src="one.jpg"> and two <img src="ph.png" data-src=\\'two.jpg\\' > and three <img src="ph.png" data-src=three.jpg> and four <img src="ph.png" data-src="four.png" alt="four">'
    >>> _lazy_images("No images here") # doctest: +ALLOW_UNICODE
    'No images here'
    """
    if placeholder is None:
        # 1px transparent gif
        placeholder = ""

    def callback(img_tag):
        match = _image_src_re.search(img_tag)
        if match is not None:
            src = match.group("src")
            quote = match.group("quote")
            quoted_src = quote + src + quote
            img_tag = img_tag.replace(
                match.group(0), 'src="{}" data-src={}'.format(placeholder, quoted_src)
            )
        return img_tag

    return _replace_images(text, callback)


def _strip_tags(text):
    """
    >>> _strip_tags("<a href='test.html'>Hello world</a>&lt;img src='foo.jpg'&gt;") # doctest: +ALLOW_UNICODE
    "Hello world&lt;img src='foo.jpg'&gt;"
    >>> _strip_tags("&#62; &#x3E; Foo") # doctest: +ALLOW_UNICODE
    '&#62; &#x3E; Foo'
    """
    try:
        # noinspection PyCompatibility
        from html.parser import HTMLParser
    except ImportError:
        # noinspection PyCompatibility
        from HTMLParser import HTMLParser

    class TagStripper(HTMLParser):
        def __init__(self, **kw):
            HTMLParser.__init__(self, **kw)
            self._fed = []

        def handle_data(self, data):
            self._fed.append(data)

        def handle_entityref(self, ref):
            self._fed.append("&{};".format(ref))

        def handle_charref(self, ref):
            self._fed.append("&#{};".format(ref))

        def get_data(self):
            return "".join(self._fed)

    tag_stripper = TagStripper() if PY2 else TagStripper(convert_charrefs=False)
    tag_stripper.feed(text)
    return tag_stripper.get_data()


__plugin_name__ = "Announcement Plugin"
__plugin_author__ = "Gina Häußge"
__plugin_description__ = "Announcements all around OctoPrint"
__plugin_disabling_discouraged__ = gettext(
    "Without this plugin you might miss important announcements "
    "regarding security or other critical issues concerning OctoPrint."
)
__plugin_license__ = "AGPLv3"
__plugin_pythoncompat__ = ">=2.7,<4"
__plugin_implementation__ = AnnouncementPlugin()

__plugin_hooks__ = {
    "octoprint.access.permissions": __plugin_implementation__.get_additional_permissions
}
